Hito 3 - Análisis de datos de Spotify

Integrantes

  • Edgar Fonseca
  • Felipe Gómez
  • Pablo Pizarro

Introducción

La música como creación humana siempre ha sido un medio de expresión cultural, en el cual se han manifestado emociones, ideas y pensamientos. En particular, los géneros musicales tienen la cualidad de estar asociados a la identidad de ciertos grupos dentro de la sociedad moderna, es por esto que es interesante analizar qué cualidades definen a un género musical, por ejemplo, en la cantidad de energía que una canción expresa o el ritmo que esta tiene. En base a esto, surge la motivación de conocer diferencias y similitudes entre los diferentes géneros musicales.

A partir de lo anterior, el objetivo principal de este proyecto corresponde a aplicar técnicas de minería de datos sobre una base de datos que contenga características técnicas de las canciones, de tal forma de ver cómo de relacionan éstas con el género y qué relación hay entre géneros. Para esto se trabaja con el dataset de Spotify que dentro de sus atributos contiene: género, artista, nombre de la canción, tempo, energía, duración, entre otras. La elección de éste dataset se debe a la gran cantidad y variedad de canciones que contiene, reconociendo que actualmente Spotify es la plataforma de música más utilizada.

En lo que respecta a las preguntas de investigación se formulan las siguientes: ¿un género musical puede quedar definido sólo por sus características técnicas?, ¿hay géneros que tienen atributos en común?, ¿qué característica(s) técnica(s) es distintiva a cada género? Asimismo, la hipótesis adoptada sobre estas preguntas es que los géneros sí son distinguibles en base a sus propiedades como el tempo o la instrumentación. Incluso, se incluye que para una canción puede haber más de un género. Ejemplo de esto puede ser con la poca distinción entre el Rap y Hip-Hop.

Para validar la hipótesis y responder las preguntas planteadas se expone una exploración de los datos, con la cual se tiene una visión global de cómo estos se distribuyen, pudiendo reconocer atributos poco relevantes y aquellos que necesitan un pre-procesamiento. Luego, se aplican técnicas de minería de datos, en éste caso, se hace clustering sobre las propiedades técnicas de las canciones y clasificación multi-etiqueta de los géneros musicales puesto que como se veran en los datos, una canción puesde tener asociada más de un genero musical.

Descripción de los datos y exploración inicial

Instalación de librerías

In [0]:
%matplotlib inline
!pip install pandas
!pip install -U -q PyDrive
!apt-get install python-pydot
!pip install pydotplus

Importación de librerías

In [0]:
from google.colab import auth
from oauth2client.client import GoogleCredentials
from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive
from scipy.spatial.distance import cdist
from sklearn.cluster import MiniBatchKMeans, KMeans
from sklearn.datasets.samples_generator import make_blobs
from sklearn.ensemble import ExtraTreesClassifier, RandomForestClassifier
from sklearn.externals.six import StringIO
from sklearn.linear_model import RidgeClassifierCV
from sklearn.metrics import confusion_matrix, f1_score, recall_score, precision_score, accuracy_score
from sklearn.metrics.pairwise import pairwise_distances_argmin
from sklearn.model_selection import train_test_split, cross_validate
from sklearn.neighbors import KNeighborsClassifier, RadiusNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier, ExtraTreeClassifier, export_graphviz
from sklearn.utils.multiclass import unique_labels
from IPython.display import Image
import pydotplus
import matplotlib.pyplot as plt
import numpy as np
import os
import pandas as pd
import random
import re
import seaborn as sns; sns.set(style="ticks", color_codes=True)
import time
import warnings

# Configura librerías
warnings.filterwarnings('always')
# warnings.simplefilter(action='ignore', category=FutureWarning)
/usr/local/lib/python3.6/dist-packages/sklearn/externals/six.py:31: DeprecationWarning: The module is deprecated in version 0.21 and will be removed in version 0.23 since we've dropped support for Python 2.7. Please rely on the official version of six (https://pypi.org/project/six/).
  "(https://pypi.org/project/six/).", DeprecationWarning)

Definición de funciones

Las funciones que se muestran a continuación tienen la finalidad de trabajar de mejor manera los datos, al momento de hacer su exploración y posterior experimentación tanto de clustering como con los distintos clasificadores.

In [0]:
def natural_sort(l):
    """
    Ordena un string con lenguaje natural
    """
    convert = lambda text: int(text) if text.isdigit() else text.lower() 
    alphanum_key = lambda key: [ convert(c) for c in re.split('([0-9]+)', key) ] 
    return sorted(l, key = alphanum_key)

# ------------------------------------------------------------------------------

def crear_matriz_correlacion(data, genre='', title='Matriz de correlación',
                             titleGenre='Matriz de correlación - Genero {0}', dropDuplicated=False):
    """
    Crea la matriz de correlación de un determinado dataset.
    """
    
    # Crea el gráfico
    plt.figure(figsize=(10,10), dpi=80)
    ax = plt.axes()
    
    # Se obtiene la data, si se pasa un genero como argumento se selecionan los datos
    # del mismo genero
    if genre != '':
        if 'genre' in data.columns:
            data = data[data['genre'] == genre]
        else:
            raise Exception('Columna genre no existe en el dataset propocionado')
            
    # Elimina repetidos
    if dropDuplicated:
        data = data.drop_duplicates(keep='first')
        
    # Calcula la correlación, la deja triangular
    corr = data.corr()
    mask = np.zeros_like(corr)
    mask[np.triu_indices_from(mask)] = True

    # Aplica máscara
    with sns.axes_style('white'):
        sns.heatmap(corr,
                    xticklabels=corr.columns.values,
                    yticklabels=corr.columns.values,
                    ax=ax,
                    annot=True,
                    # cmap="YlGnBu",
                    linewidths=0.5,
                    square=True,
                    mask=mask)

    # Modifica el gráfico
    if genre == '':
        ax.set_title(title, fontsize=20)
    else:
        ax.set_title(titleGenre.format(genre), fontsize=20)
    plt.show()

# ------------------------------------------------------------------------------

def generar_grafico_particion_data(data, col, title, naturalsort=True, doprint=True,
                                   doplot=True, plotlim=0):
    """
    Genera la partición de la data por un dato categorico.
    """
    if col is not None:
        g = data[col].unique()
    else:
        g = data.unique()
    if naturalsort:
        g = natural_sort(g)
    else:
        g.sort()
    u = []

    # Calculamos el total de 
    for i in g:
        if col is not None:
            gi = data[col] == i
        else:
            gi = data == i
        t = gi.sum()
        u.append(t)
        
    # Calcula la suma
    uplot = []
    gplot = []
    if plotlim == 0:
        uplot = u
        gplot = g
    else:
        ut = np.sum(u)
        for i in range(len(g)):
            por = u[i]/ut * 100
            if por >= plotlim:
                uplot.append(u[i])
                gplot.append(g[i])
    if doprint:
        ut = np.sum(u)
        for i in range(len(g)):
            por = round(u[i]/ut * 100, 1)
            print ('{0}{1}\t{2}%'.format(g[i].ljust(20), str(u[i]).ljust(10), por))

        print('\nTotal:', ut)

    # Grafica
    if doplot:
        fig, ax = plt.subplots(figsize=(6,6), dpi=100)
        ax.pie(uplot, labels=gplot, autopct='%d%%', shadow=False, startangle=90)
        ax.set_title(title, fontsize=12)
        _ = ax.axis('equal')
    return {'genre': g, 'total': u}


# ------------------------------------------------------------------------------

def genera_grafico_genero_tramo_popularidad(data, pmin, pmax, doprint=True,
                                            doplot=True, plotlim=0):
    """
    Selecciona un tramo de popularidad de la data, luego grafica la distribución
    de generos dentro de la misma. pmin y pmax están entre 0 y 100.
    """
    dataPopularity = data['popularity'] # La primera columna
    dataP = (dataPopularity >= float(pmin)/100) & (dataPopularity <= float(pmax)/100) # Vector mascara
    dataD = data[dataP] # Nuevo DF
    title = 'Tramo popularidad {0}-{1}\n'.format(pmin, pmax)
    return generar_grafico_particion_data(dataD, 'genre', title, naturalsort=True,
                                          doprint=doprint, doplot=doplot, plotlim=plotlim)

# ------------------------------------------------------------------------------

def genera_grafico_genero_popularidad(data, tramos, title='Distribución géneros por tramo de popularidad\n',
                                      colorbar=False, fz=10, normalize=True, porcentaje='popularidad'):
    """
    Grafica la distribución de géneros en cada uno de los tramos de popularidad.
    """
    tramoLabel = []
    
    # Obtiene los generos
    g = data['genre'].unique()
    ng = len(g)
    nu = len(tramos)
    
    # Genera la matriz
    mat = np.zeros((ng, nu))
    
    for i in range(len(tramos)):
        tramoLabel.append('{0}-{1}'.format(*tramos[i]))
        d = genera_grafico_genero_tramo_popularidad(data, tramos[i][0], tramos[i][1], doprint=False, doplot=False)
        gi = d['genre']
        ui = d['total']
        for j in range(len(gi)):
            # Busca el genero (fila)
            for k in range(len(g)):
                if g[k] == gi[j]:
                    mat[k, i] = ui[j]
                    break
    
    if porcentaje=='none':
        pass
    elif porcentaje=='popularidad':
        for i in range(len(tramos)): # Suma por columna y divide
            total = np.sum(mat[:, i])
            if total == 0:
                continue
            for k in range(len(g)):
                mat[k, i] = mat[k, i] / total * 100
    elif porcentaje=='genero':
        for i in range(len(g)): # Suma por columna y divide
            total = np.sum(mat[i, :])
            if total == 0:
                continue
            for k in range(len(tramos)):
                mat[i, k] = mat[i, k] / total * 100
    else:
        raise Exception('Modo porcentaje incorrecto, valores posibles:none,popularidad,genero')
    
    aspect = min(1, mat.shape[1]/mat.shape[0])
    fig, ax = plt.subplots(figsize=(10,10), dpi=100)
    im = ax.imshow(mat, interpolation='nearest', cmap=plt.cm.Blues, aspect=aspect) # Controlar el aspecto para el "ancho"
    if colorbar:
        ax.figure.colorbar(im, ax=ax)

    ax.set(xticks=np.arange(mat.shape[1]),
           yticks=np.arange(mat.shape[0]),
           xticklabels=tramoLabel,
           yticklabels=g,
           ylabel='Géneros',
           xlabel='Tramo popularidad')
    
    ax.set_title(title, fontsize=20)

    # Rota el eje
    plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
             rotation_mode='anchor')

    # Escribe los totales en cada data
    fmt = '.1f' if normalize else 'd'
    thresh = mat.max() / 2.
    for i in range(mat.shape[0]):
        for j in range(mat.shape[1]):
            if mat[i, j] != 0:
                if fmt == 'd':
                    t = int(mat[i, j])
                else:
                    t = format(mat[i, j], fmt)
            else:
                t = 0
            ax.text(j, i, t, ha='center', va='center',
                    color='white' if mat[i, j] > thresh else 'black', fontsize=fz)
    fig.tight_layout()

# ------------------------------------------------------------------------------

def run_classifier(clf, X, X_train, X_test, y, y_train, y_test, num_tests=100, cv=5, crossValidation=False):
    """
    Corre un set de clasificadores. Corre cross-validation para obtener accuracy, recall y precisiton.
    """
    
    metrics = {'f1-score': [], 'precision': [], 'recall': [], 'accuracy': []}
    tini = time.time()
    
    # Entrena
    for _ in range(num_tests):
        clf.fit(X_train, y_train) # Entrenamos con X_train y clases y_train
        y_pred = clf.predict(X_test) # Predecimos con nuevos datos (los de test X_test)
        metrics['y_pred'] = y_pred
        metrics['y_prob'] = clf.predict_proba(X_test)[:, 1]
        metrics['accuracy'].append(accuracy_score(y_test, y_pred, normalize=True)) 
        metrics['f1-score'].append(f1_score(y_test, y_pred, average='weighted', labels=np.unique(y_pred))) 
        metrics['recall'].append(recall_score(y_test, y_pred, average='weighted', labels=np.unique(y_pred)))
        metrics['precision'].append(precision_score(y_test, y_pred, average='weighted', labels=np.unique(y_pred)))
    
    metrics['accuracy-mean'] = np.array(metrics['accuracy']).mean()
    metrics['f1-score-mean'] = np.array(metrics['f1-score']).mean()
    metrics['precision-mean'] = np.array(metrics['precision']).mean()
    metrics['recall-mean'] = np.array(metrics['recall']).mean()
    metrics['time'] = time.time() - tini
    
    # Calcula cross validation
    if crossValidation:
        scoring = ['precision_macro', 'recall_macro', 'accuracy', 'f1_macro']
        cv_results = cross_validate(clf, X, y, cv=cv, scoring=scoring, return_train_score=True)
        metrics['cv-precision'] = cv_results['test_precision_macro']
        metrics['cv-recall'] = cv_results['test_recall_macro']
        metrics['cv-f1-score'] = cv_results['test_f1_macro']
        metrics['cv-accuracy'] = cv_results['test_accuracy']

        # Calcula el promedio
        metrics['cv-precision-mean'] = np.mean(metrics['cv-precision'])
        metrics['cv-recall-mean'] = np.mean(metrics['cv-recall'])
        metrics['cv-f1-score-mean'] = np.mean(metrics['cv-f1-score'])
        metrics['cv-accuracy-mean'] = np.mean(metrics['cv-accuracy'])
    else:
        metrics['cv-precision-mean'] = metrics['precision-mean']
        metrics['cv-recall-mean'] = metrics['recall-mean']
        metrics['cv-f1-score-mean'] = metrics['f1-score-mean']
        metrics['cv-accuracy-mean'] = metrics['accuracy-mean']
    
    return metrics

# ------------------------------------------------------------------------------

def crear_matriz_confusion(y_true, y_pred, normalize=False, title=None,
                           cmap=plt.cm.Blues, model_name='', fz=10, modoPorcentaje=False,
                           figsize=10, dpi=100):
    """
    Esta función crea la matriz de confusión a partir de una clasificación.
    """
    
    # Calcula la matriz de confusión
    cm = confusion_matrix(y_true, y_pred)
    
    # Modo porcentaje
    if modoPorcentaje:
        cm2 = np.zeros((len(cm),len(cm)))
        for i in range(len(cm)):
            s = 0
            for j in range(len(cm)):
                s += cm[i][j]
            s = float(s)
            if s == 0: # Si nada se predijo continua
                continue
            for j in range(len(cm)):
                cm2[i,j]=float(1.0* cm[i][j])/s * 100
        cm = cm2
    
    # Sólo se usan las etiquetas de las clases
    classes = unique_labels(y_true, y_pred)
    
    fig, ax = plt.subplots(figsize=(figsize,figsize), dpi=dpi)
    im = ax.imshow(cm, interpolation='nearest', cmap=cmap)
    ax.figure.colorbar(im, ax=ax)
    # We want to show all ticks*...
    ax.set(xticks=np.arange(cm.shape[1]),
           yticks=np.arange(cm.shape[0]),
           # ... and label them with the respective list entries
           xticklabels=classes, yticklabels=classes,
           ylabel='Clase verdadera',
           xlabel='Clase predicha')
    if title is not None:
        ax.set_title('{0} - {1}\n'.format(title, model_name), fontsize=20)

    # Rota el eje
    plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
             rotation_mode='anchor')

    # Escribe los totales en cada data
    fmt = '2f' if normalize else 'd'
    
    # Si muestra porcentajes
    if modoPorcentaje:
        fmt = '.1f'
    
    thresh = cm.max() / 2.
    for i in range(cm.shape[0]):
        for j in range(cm.shape[1]):
            d = cm[i, j]
            if modoPorcentaje:
                if d < 0.1:
                    d = 0
            if d != 0:
                d = format(d, fmt)
            ax.text(j, i, d, ha='center', va='center',
                    color='white' if cm[i, j] > thresh else 'black', fontsize=fz)
    fig.tight_layout()
    return ax

# ------------------------------------------------------------------------------

def grafico_confusion_genero(y_true, y_pred, genre, model_name='', cutPor=1, showOther=True):
    """
    Crea un gráfico de la confusión por género (torta).
    """
    
    # Calcula la matriz de confusión
    cm = confusion_matrix(y_true, y_pred)
        
    # Sólo se usan las etiquetas de las clases
    classes = unique_labels(y_true, y_pred)
    
    # Busca el genero
    j = -1
    for i in range(len(classes)):
        if classes[i].lower() == genre.lower():
            j = i
            break
    if j == -1:
        raise Exception('Genero {0} no existe'.format(genre))
        
    title = r"$\bf{" + r'Género\ '+ genre + "}$" + ' - {0}\n'.format(model_name)
    
    # Corta los resultados a un determinado porcentaje
    dt = cm[j, :] # Datos
    dt2 = [] # Datos con porcentaje filtrado
    g = [] # Géneros filtrados
    s = np.sum(dt)
    explode = []
    ignored = 0
    for i in range(len(dt)):
        por = dt[i]/s # Porcentaje
        if por >= cutPor/100:
            dt2.append(dt[i])
            if i == j:
                g.append(r'$\bf{'+ classes[i] +'}$')
                explode.append(0.15)
            else:
                g.append(classes[i])
                explode.append(0)
        else:
            ignored += dt[i]
    if showOther:
        dt2.append(ignored)
        g.append(r'$\it{Otros}$')
        explode.append(0.05)
    
    # Creamos el gráfico
    fig, ax = plt.subplots(figsize=(6,6), dpi=100)
    ax.pie(dt2, labels=g, explode=explode, autopct='%.1f%%', shadow=False,
           startangle=90, pctdistance=0.75)
    ax.set_title(title, fontsize=12)
    ax.set_aspect(1)
    _ = ax.axis('equal')

# ------------------------------------------------------------------------------

def generar_grafico_kmeans(dataset, dataWgenre, k_means, col_i, col_j, cluster=[],
                           genre=[], grid=True, plotKmean=True, plotKcenter=True,
                           kSize=3, cSize=20, cWidth=0.5, gSize=7, plotPos=None, fig=None,
                           title='KMEANS', fontsize=10, ticksize=10, plotStyle='.', kWidth=0.5):
    """
    Grafica el resultado de kmeans para un determinado dataset.
    """
    
    # Obtiene nombre de columnas
    colname = dataset.columns
    
    if col_i == col_j:
        raise Exception('La columna no puede ser la misma')
    if col_i < 0 or col_j < 0:
        raise Exception('ID columna debe ser >=0')
    if col_i >= len(colname) or col_j >= len(colname):
        raise Exception('Se excede el número de columnas')
    
    if plotPos is None:
        print('Graficando clusters KMEANS, columnas {0}x{1} ({2} x {3})'.format(col_i, col_j, colname[col_i], colname[col_j]))
    
    # Obtiene el total de clusters
    k_means_cluster_centers = np.sort(k_means.cluster_centers_, axis=0)
    k_means_labels = pairwise_distances_argmin(dataset, k_means_cluster_centers)
    n_clusters = len(k_means_cluster_centers)
    
    if title is not None:
        title = '{0} - {1} clusters'.format(title, n_clusters)
    
    # Genera lista de colores
    def htmlcolor(r, g, b, alpha):
        def _chkarg(a):
            if isinstance(a, int):
                if a < 0:
                    a = 0
                elif a > 255:
                    a = 255
            elif isinstance(a, float):
                if a < 0.0:
                    a = 0
                elif a > 1.0:
                    a = 255
                else:
                    a = int(round(a*255))
            else:
                raise ValueError('Color invalido')
            return a
        r = _chkarg(r)
        g = _chkarg(g)
        b = _chkarg(b)
        return '#{:02x}{:02x}{:02x}'.format(r,g,b)
    
    def get_cmap(n, name='hsv'):
        return plt.cm.get_cmap(name, n)
    
    cmap = get_cmap(n_clusters + len(genre) + 1)
    
    # Colores para los clusters
    colors = []
    for i in range(n_clusters):
        colors.append(htmlcolor(*cmap(i)))
        
    # Colores para los generos
    gcolors = []
    for i in range(n_clusters, n_clusters + len(genre)):
        gcolors.append(htmlcolor(*cmap(i)))
        
    # Crea figura
    if plotPos is None: # Se crea una figura de forma autonoma
        fig = plt.figure(figsize=(7, 7), dpi=100)
        ax = plt.gca()
    else:
        ax = plt.subplot(*plotPos)
        
    # ax.set_facecolor('xkcd:salmon')
    # ax.set_facecolor((1.0, 0.47, 0.42))
    
    # Si no se pasa que cluster graficar los grafica todos
    if len(cluster) == 0:
        cluster = [*range(1, n_clusters+1)]
    else:
        for i in cluster:
            if i <= 0:
                raise Exception('ID cluster entre 1 y {0}'.format(n_clusters))
    
    # Grafica los kmean
    if plotKmean:
        for k in cluster:
            k -= 1
            col = colors[k]
            my_members = k_means_labels == k
            ax.plot(dataset.iloc[my_members, col_i], dataset.iloc[my_members, col_j], plotStyle,
                    markerfacecolor=col, markeredgecolor=col, markeredgewidth=kWidth, markersize=kSize, marker=plotStyle, label='_nolegend_')
    
    plt.xlabel(colname[col_i], fontsize=fontsize)
    plt.ylabel(colname[col_j], fontsize=fontsize)
    if title is not None:
        plt.title(title)
        
    ax.tick_params(axis='both', which='major', labelsize=ticksize)
    ax.tick_params(axis='both', which='minor', labelsize=ticksize)
    if plotPos is not None:
        plt.tick_params(top=False, bottom=False, left=False, right=False, labelleft=False, labelbottom=True)
    
    # Grafica un genero en específico
    glist = dataWgenre['genre'].unique()
    k = 0
    for i in genre:
        if i not in glist:
            raise Exception('Genero {0} no conocido'.format(i))
        col = gcolors[k]
        my_members = dataWgenre['genre'] == i
        my_members = my_members.values
        ax.plot(dataset.iloc[my_members, col_i], dataset.iloc[my_members, col_j], plotStyle,
                markerfacecolor=col, markeredgecolor=col, marker='.', label=i, markersize=gSize)
        k += 1
        
    # Grafica los centros
    if plotKcenter:
        for k, col in zip(range(n_clusters), colors):
            if k+1 not in cluster:
                continue
            my_members = k_means_labels == k
            cluster_center = k_means_cluster_centers[k]
            ax.plot(cluster_center[col_i], cluster_center[col_j], plotStyle, markerfacecolor=col,
                    markeredgecolor='k', markersize=cSize, label='_nolegend_', markeredgewidth=cWidth)
            if plotPos is None:
                print('Posicion centro cluster {0}: ({1},{2})'.format(k+1, cluster_center[col_i], cluster_center[col_j]))
    
    if grid:
        plt.grid()
        
    # Carga la leyenda
    if len(genre) != 0 and plotPos is None:
        plt.legend(loc='upper right')

# ------------------------------------------------------------------------------

def generar_grafico_kmeans_pairs(dataset, dataWgenre, k_means, cluster=[],
                           genre=[], grid=True, plotKmean=True, plotKcenter=True,
                           kSize=1, cSize=6, plotPos=None):
    """
    Llama a la función de creación de Kmeans para cada combinación de columnas del dataset
    """
    # Obtiene total columnas
    tc = len(dataset.columns)
    
    # Ensamblo la figura
    fig = plt.figure(figsize=(10, 10), dpi=300)
    k = 0
    sp = 1
    for i in range(tc):
        for j in range(tc):
            if j <= i:
                continue
            generar_grafico_kmeans(dataset, dataWgenre, k_means, i, j, cluster=cluster,
                           genre=genre, grid=False, plotKmean=plotKmean, plotKcenter=plotKcenter,
                           kSize=1, cSize=0, cWidth=0.1, gSize=3, plotPos=(sp*tc, sp*tc, i*(sp*tc) + sp*(j-1) + 1), fig=fig,
                           title=None, fontsize=5, ticksize=5, plotStyle='.', kWidth=0)
    plt.subplots_adjust(right=1.5, bottom=0.05)
    fig.tight_layout()

# ------------------------------------------------------------------------------
    
def generar_tabla_generos(cluster):
    """
    Genera una tabla de generos y porcentajes de genero en cada cluster.
    """
    n_clusters = cluster['nclusters']
    genrePor = cluster['genrePor']
    title = '{0}\t'.format('Genero\\Cluster'.ljust(genreStringLen))
    for j in range(n_clusters):
        title += '{0}\t'.format(j+1)
    print(title+'\n')
    for i in genres:
        s = '{0}\t'.format(i.ljust(genreStringLen))
        for j in range(n_clusters):
            s += '{0}\t'.format(round(genrePor[i][j]*100, 1))
        print(s)

# ------------------------------------------------------------------------------
        
def generar_particion_genero_cluster(mat, title, fz=7, colorbar=True):
    """
    Genera la distribución de genero por cada cluster computado.
    """
    
    aspect = min(1, mat.shape[1]/mat.shape[0])
    fig, ax = plt.subplots(figsize=(10,10), dpi=100)
    im = ax.imshow(mat, interpolation='nearest', cmap=plt.cm.Blues, aspect=aspect) # Controlar el aspecto para el "ancho"
    if colorbar:
        ax.figure.colorbar(im, ax=ax)

    clusterTicks = []
    for i in range(n_clusters):
        clusterTicks.append('{0}'.format(i+1))

    ax.set(xticks=np.arange(mat.shape[1]),
           yticks=np.arange(mat.shape[0]),
           xticklabels=clusterTicks,
           yticklabels=genres,
           ylabel='Géneros',
           xlabel='N° Cluster')
    
    title = '{0} - {1} clusters'.format(title, mat.shape[1])
    ax.set_title(title, fontsize=20)

    # Rota el eje
    plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
             rotation_mode='anchor')

    # Escribe los totales en cada data
    normalize = True
    fmt = '.1f' if normalize else 'd'
    thresh = mat.max() / 2.
    for i in range(mat.shape[0]):
        for j in range(mat.shape[1]):
            if mat[i, j] != 0:
                t = format(mat[i, j], fmt)
            else:
                t = 0
            ax.text(j, i, t, ha='center', va='center',
                    color='white' if mat[i, j] > thresh else 'black', fontsize=fz)
    fig.tight_layout()

Inicia sesión en Google

In [0]:
# Indica si usa google o no
usaGoogle = True

# Indica si se usa drive para acceder al archivo o se debe subir
usaDrive = True

if usaGoogle:
    
    if usaDrive:
        from google.colab import auth
        from oauth2client.client import GoogleCredentials
        from pydrive.auth import GoogleAuth
        from pydrive.drive import GoogleDrive

        # Descarga de archivos 
        auth.authenticate_user()
        gauth = GoogleAuth()
        gauth.credentials = GoogleCredentials.get_application_default()
        drive = GoogleDrive(gauth)
        link = 'https://drive.google.com/open?id=1_BGzZEDwgHT_xd-VJkwQ9p6Q4ob6nmcx' # ID de la carpeta de google drive
        fluff, file_id = link.split('=')
    
    else:
        from google.colab import files
        import io
        uploaded = files.upload()
/usr/local/lib/python3.6/dist-packages/google/colab/auth.py:141: ResourceWarning: unclosed <ssl.SSLSocket fd=64, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('172.28.0.2', 37238), raddr=('74.125.139.84', 443)>
  if _check_adc():

Carga la data

In [0]:
# Obtiene la data de google
if usaGoogle:
    if usaDrive:
        downloaded = drive.CreateFile({'id':file_id}) 
        downloaded.GetContentFile('SpotifyFeatures.csv') 
    else:
        data = pd.read_csv(io.BytesIO(uploaded['SpotifyFeatures.csv']))
data = pd.read_csv('SpotifyFeatures.csv')

# Obtiene número de columnas y de filas
data_size = {
    'col': data.shape[1],
    'row': data.shape[0]
}

# Imprime la data
data.head()
Out[0]:
genre artist_name track_name track_id popularity acousticness danceability duration_ms energy instrumentalness key liveness loudness mode speechiness tempo time_signature valence
0 Opera Giuseppe Verdi Stiffelio, Act III: Ei fugge! … Lina, pensai c... 7EsKYeHtTc4H4xWiTqSVZA 21 0.986 0.313 490867 0.23100 0.000431 C# 0.0964 -14.287 Major 0.0547 86.001 4/4 0.0886
1 Opera Giacomo Puccini Madama Butterfly / Act 1: ... E soffitto e pareti 7MfmRBvqaW0I6UTxXnad8p 18 0.972 0.360 176797 0.20100 0.028000 D# 0.1330 -19.794 Major 0.0581 131.798 4/4 0.3690
2 Opera Giacomo Puccini Turandot / Act 2: Gloria, gloria, o vincitore 7pBo1GDhIysyUMFXiDVoON 10 0.935 0.168 266184 0.47000 0.020400 C 0.3630 -8.415 Major 0.0383 75.126 3/4 0.0696
3 Opera Giuseppe Verdi Rigoletto, Act IV: Venti scudi hai tu detto? 02mvYZX5aKNzdqEo6jF20m 17 0.961 0.250 288573 0.00605 0.000000 D 0.1200 -33.440 Major 0.0480 76.493 4/4 0.0380
4 Opera Giuseppe Verdi Don Carlo / Act 4: "Ella giammai m'amò!" 03TW0jwGMGhUabAjOpB1T9 19 0.985 0.142 629760 0.05800 0.146000 D 0.0969 -23.625 Major 0.0493 172.935 4/4 0.0382

Operación de la data

En una primera instancia, en vista de los atributos las características omitidas para la exploración de datos son: key, artist_name, track_name, mode y time_signature dado que no correponden a objetos numéricos y por ende, no es posible obtener métricas.

De esta forma los datos que restan son:

  • género musical (genre)
  • popularidad
  • acousticness
  • danceability
  • duration_ms
  • energy
  • instrumentalness
  • liveness
  • loudness
  • speechiness
  • tempo
  • valence

Esto se hace en un comienzo para tener una noción básica de los datos. Sin embargo, al momento de realizar clustering y clasificación, estás variables serán incluidas de forma numérica para ver su correlación con el resto de los atributos.

Por otra parte, se observa que hay canciones repetidas, las cuales sólo se diferencias en su género. Esto quiere decir que una canción puede estar asociada a más de un género. Debido a esto, se opta por eliminar filas repetidas (sin distinguir sobre el género) para ver cómo es la distribución de características dentro del dataset.

In [0]:
# Borramos columnas sin usar
for i in ['key', 'artist_name', 'track_name', 'mode', 'time_signature']:
    if i in data.columns:
        del data[i]

# Crea data numérica
data_num = data.iloc[:,2:data_size['col']]
data_num_prev = data_num.shape

# Elimina datos duplicados
data_num = data_num.drop_duplicates(keep='first')
data_num_post = data_num.shape

print('Se eliminaron un total de {0} datos duplicados'.format(data_num_prev[0] - data_num_post[0]))
print('La nueva data numérica tiene un tamaño de {0}x{1}'.format(*data_num_post))
data_num.head()
Se eliminaron un total de 69357 datos duplicados
La nueva data numérica tiene un tamaño de 158802x11
Out[0]:
popularity acousticness danceability duration_ms energy instrumentalness liveness loudness speechiness tempo valence
0 21 0.986 0.313 490867 0.23100 0.000431 0.0964 -14.287 0.0547 86.001 0.0886
1 18 0.972 0.360 176797 0.20100 0.028000 0.1330 -19.794 0.0581 131.798 0.3690
2 10 0.935 0.168 266184 0.47000 0.020400 0.3630 -8.415 0.0383 75.126 0.0696
3 17 0.961 0.250 288573 0.00605 0.000000 0.1200 -33.440 0.0480 76.493 0.0380
4 19 0.985 0.142 629760 0.05800 0.146000 0.0969 -23.625 0.0493 172.935 0.0382

Correlación entre la data, histogramas

Con la información depurada, se realizan gráficos que muestran la relación de las diferentes características y los histogramas asociados a cada una.

In [0]:
# Creo un subset de los datos
data_num_subset_por = 0.05
data_num_subset = data_num.sample(frac=data_num_subset_por, replace=True, random_state=1)
print('El subset a graficar tiene un tamaño de {0}x{1} ({2}%)'.format(data_num_subset.shape[0], data_num_subset.shape[1], data_num_subset_por*100))
g = sns.pairplot(data_num_subset.iloc[:, 0:10],
                 diag_kind='kde',
                 # markers='+',
                 # plot_kws=dict(s=50, edgecolor='b', linewidth=1),
                 # diag_kws=dict(shade=True)
                 ) # Parametro kind='reg' agrega una recta
plt.show()
El subset a graficar tiene un tamaño de 7940x11 (5.0%)
/usr/local/lib/python3.6/dist-packages/matplotlib/artist.py:315: ResourceWarning: unclosed <ssl.SSLSocket fd=65, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('172.28.0.2', 37264), raddr=('173.194.216.95', 443)>
  for oid, func in self._propobservers.items():

Matriz de correlación

Se genera la matriz de correlación para visualizar qué variables están más correlacionadas con el resto para el set de datos numéricos registrados. Si una variable posee poca correlación con el resto quiere decir que no es una métrica que entregue mucha información si se quiere hacer clasificación, clustering, u cualquier otra metodología numérica. Primero se comienza con una correlación entre los diferentes géneros (la imagen se extrae de la exploración realizada en el hito 1):

texto alternativo

Adicionalmente se realiza una matriz de correlación sólo con los atributos técnicos de las variables. Esto, para visualizar que características técnicas se relacionan más entre sí.

In [0]:
crear_matriz_correlacion(data)

Tal como se visualiza en la gráfica anterior el atributo duration_ms (duración de cada canción en milisegundos) no posee una buena correlación con el resto de atributros. Con lo cual cualquier análisis se realizará sin considerar este último.

Luego, es de interés ver cómo se relacionan las características técnicas dentro de cada género musical. Para esto, se realizan la matriz de correlación expuesta anteriormente pero filtrando las canciones por género. Como ejemplo se muestran las matrices asociadas al Rock y Ópera:

In [0]:
crear_matriz_correlacion(data, genre='Rock')
In [0]:
crear_matriz_correlacion(data, genre='Opera')

Histograma de datos y densidad

Por otra parte, se opta por hacer un histograma para todos los atributos de dataset y así observar qué valores suelen tener las canciones para cada una de las características.

In [0]:
plt.figure(figsize=(10,10), dpi=100)
ax = plt.axes()
data_num.hist(ax=ax, bins=20, grid=False)
plt.show()
/usr/local/lib/python3.6/dist-packages/IPython/core/interactiveshell.py:2882: UserWarning: To output multiple subplots, the figure containing the passed axes is being cleared
  exec(code_obj, self.user_global_ns, self.user_ns)

Dentro de los atributos restantes se tiene que el atributo de popularidad no corresponde a una característica técnica. En efecto, en el hito 1 se analizó qué representaba éste atributo y se llegó a la conclusión que correspondía a la popularidad que tenía la canción a la fecha que se obtuvo el dataset. Así, tal y como se observa en la tabla que se muestra a continuación, se tiene que el top 5 de las canciones más populares fueron lanzadas al mercado hace menos de un año.

texto alternativo

Es por ello que en el hito 2 se optó por eliminar éste atributo al momento de realizar los experimentos. Sin embargo, tal y como se expondrá más adelante, puede observarse una correlación entre la popularidad y los géneros musicales.

En efecto, éste atributo se refiere a la popularidad que tiene una canción dentro de la plataforma de Spotify y no a la "fama" real que tiene, tuvo o tendrá una canción. De esta manera, el atributo nos indica el gusto actual que poseen los usuarios frente a los diferentes géneros musicales.

Tal y como se observa en la tabla anterior, los géneros más populares en la actualidad son Pop, Electrónica, Reggaeton, entre otros. Mientras que hay géneros que son menos escuchados como el caso de la Ópera. Por lo tanto, dado que el atributo, pese a no ser técnico, entrega información importante para analizar géneros, se incorpora al dataset.

Preprocesamiento de los datos

Carga de datos

A partir de la exploración de datos y observaciones realizadass anteriormente, se procede a trabajar el dataset con las siguientes modificaciones:

  • Se reincorporan los datos repetidos,. Esto se hace para aplicar clasificadores multi-etiqueta.
  • Los atributos que en un comienzo se eliminaron por no ser numéricos se reicorporan, haciendo la debida transformación.
  • Se normalizan todos atributos.
  • Los atributos relacionados con el artista y el nombre de la pista seguirán sin considerarse.

En esta sección se normaliza la data y se crean valores numéricos para las columnas tipo "string".

In [0]:
# Carga la nueva data
originData = pd.read_csv('SpotifyFeatures.csv')

# Borramos columnas sin usar
for i in ['artist_name', 'track_name', 'track_id']:
    if i in originData.columns:
        del originData[i]
        
# Se imprime la data "raw"
originData.head()
Out[0]:
genre popularity acousticness danceability duration_ms energy instrumentalness key liveness loudness mode speechiness tempo time_signature valence
0 Opera 21 0.986 0.313 490867 0.23100 0.000431 C# 0.0964 -14.287 Major 0.0547 86.001 4/4 0.0886
1 Opera 18 0.972 0.360 176797 0.20100 0.028000 D# 0.1330 -19.794 Major 0.0581 131.798 4/4 0.3690
2 Opera 10 0.935 0.168 266184 0.47000 0.020400 C 0.3630 -8.415 Major 0.0383 75.126 3/4 0.0696
3 Opera 17 0.961 0.250 288573 0.00605 0.000000 D 0.1200 -33.440 Major 0.0480 76.493 4/4 0.0380
4 Opera 19 0.985 0.142 629760 0.05800 0.146000 D 0.0969 -23.625 Major 0.0493 172.935 4/4 0.0382

A continuación se convierten todos los datos string a números.

In [0]:
keyU = originData['key'].unique()
modeU = originData['mode'].unique()
timeU = originData['time_signature'].unique()

# Ordenamos por orden
keyU.sort()
modeU.sort()
timeU.sort()

print('Posibles datos de atributo key:', keyU)
print('Posibles datos de atributo mode:', modeU)
print('Posibles datos de atributo time_signature:', timeU)
Posibles datos de atributo key: ['A' 'A#' 'B' 'C' 'C#' 'D' 'D#' 'E' 'F' 'F#' 'G' 'G#']
Posibles datos de atributo mode: ['Major' 'Minor']
Posibles datos de atributo time_signature: ['0/4' '1/4' '3/4' '4/4' '5/4']
In [0]:
keyD = {}
for i in range(len(keyU)):
    keyD[keyU[i]] = i / (len(keyU)-1)
modeD = {}
for i in range(len(modeU)):
    modeD[modeU[i]] = i / (len(modeU)-1)
timeD = {}
for i in range(len(timeU)):
    timeD[timeU[i]] = i / (len(timeU)-1)
In [0]:
# Creamos un mapa, solo si no han sido renombradas
if 'key' in originData.columns:
    originData['keyM'] = originData['key'].map(lambda x: keyD[x])
    originData['modeM'] = originData['mode'].map(lambda x: modeD[x])
    originData['time_signatureM'] = originData['time_signature'].map(lambda x: timeD[x])

# Elimina las columnas antiguas
for i in ['key', 'mode', 'time_signature']:
    if i in originData.columns:
        del originData[i]

# Graficamos la data
originData.head()
Out[0]:
genre popularity acousticness danceability duration_ms energy instrumentalness liveness loudness speechiness tempo valence keyM modeM time_signatureM
0 Opera 21 0.986 0.313 490867 0.23100 0.000431 0.0964 -14.287 0.0547 86.001 0.0886 0.363636 0.0 0.75
1 Opera 18 0.972 0.360 176797 0.20100 0.028000 0.1330 -19.794 0.0581 131.798 0.3690 0.545455 0.0 0.75
2 Opera 10 0.935 0.168 266184 0.47000 0.020400 0.3630 -8.415 0.0383 75.126 0.0696 0.272727 0.0 0.50
3 Opera 17 0.961 0.250 288573 0.00605 0.000000 0.1200 -33.440 0.0480 76.493 0.0380 0.454545 0.0 0.75
4 Opera 19 0.985 0.142 629760 0.05800 0.146000 0.0969 -23.625 0.0493 172.935 0.0382 0.454545 0.0 0.75

Normalización de los datos

A continuación, se normaliza cada dato de las columnas con respecto a su máximo, esto permite obtener mejores resultados y poder realizar la clasificación.

In [0]:
k = 0
for i in originData.columns:
    if i=='genre':
        continue
    
    # Obtienemos el maximo y el minimo absoluto
    vmin = originData[i].min()
    vmax = originData[i].max()
    v = max(abs(vmin), abs(vmax))
    originData[i] = originData[i].map(lambda x: (x-vmin)/v)
    print('Columna {3}:{0}, tmin:{1}, max:{2}'.format(i, vmin/v, vmax/v, k))
    k += 1
    
originData.head()
Columna 0:popularity, tmin:0.0, max:1.0
Columna 1:acousticness, tmin:1.004016064257028e-06, max:1.0
Columna 2:danceability, tmin:0.05764944275582574, max:1.0
Columna 3:duration_ms, tmin:0.0027929464820021623, max:1.0
Columna 4:energy, tmin:2.032032032032032e-05, max:1.0
Columna 5:instrumentalness, tmin:0.0, max:1.0
Columna 6:liveness, tmin:0.00967, max:1.0
Columna 7:loudness, tmin:-1.0, max:0.030215223897668564
Columna 8:speechiness, tmin:0.02295760082730093, max:1.0
Columna 9:tempo, tmin:0.1266593842767086, max:1.0
Columna 10:valence, tmin:0.0, max:1.0
Columna 11:keyM, tmin:0.0, max:1.0
Columna 12:modeM, tmin:0.0, max:1.0
Columna 13:time_signatureM, tmin:0.0, max:1.0
Out[0]:
genre popularity acousticness danceability duration_ms energy instrumentalness liveness loudness speechiness tempo valence keyM modeM time_signatureM
0 Opera 0.21 0.989959 0.259473 0.085605 0.231211 0.000431 0.08673 0.727644 0.033609 0.231905 0.0886 0.363636 0.0 0.75
1 Opera 0.18 0.975903 0.307092 0.029046 0.201181 0.028028 0.12333 0.622662 0.037125 0.422847 0.3690 0.545455 0.0 0.75
2 Opera 0.10 0.938754 0.112563 0.045143 0.470450 0.020420 0.35333 0.839583 0.016649 0.186564 0.0696 0.272727 0.0 0.50
3 Opera 0.17 0.964858 0.195643 0.049175 0.006036 0.000000 0.11033 0.362525 0.026680 0.192263 0.0380 0.454545 0.0 0.75
4 Opera 0.19 0.988955 0.086221 0.110618 0.058038 0.146146 0.08723 0.549631 0.028025 0.594360 0.0382 0.454545 0.0 0.75

Análisis de atributos

Una vez agregados los nuevos atributos de forma numérica, se vuelven a realizar matrices de correlación para podr discriminar si estos son relevantes para el proceso de clasificación.

In [0]:
crear_matriz_correlacion(originData)

Mediante prueba y error, una vez aplicados algunos algoritmos de clasificación y clustering, se decide descartar los atributos de duration_ms, keyM y modeM, debido a que empeoraban los resultados de los algoritmos. Si bien, de la matriz de correlación mostrada anteriormente se ve que estos atributos no correlacionan mucho con los demas (lo que nos dice que no introducen información reduntate al dataset), se puede deducir que empeoran los resultados del claificadores porque añaden ruido a estos. Notar que la duración ya se había descartado de la exploración inicial de datos.

In [0]:
originDataF = originData.copy(deep=True)
for i in ['duration_ms', 'keyM', 'modeM']:
    if i in originDataF.columns:
        del originDataF[i]
        
crear_matriz_correlacion(originDataF)

Por otro lado, del trabajo hecho en el Hito 2 se observó que existen géneros iguales y géneros desbalanceados.

En efecto se tiene que todas las canciones catalogadas como Alternative, también se clasifican como Children's Music y Blues. Además hay géneros como A Capella que tiene muy pocas canciones en comparación con el resto de los géneros.

In [0]:
_= generar_grafico_particion_data(originDataF, 'genre', 'Distribución de los datos por géneros\n\n')
A Capella           119       	0.1%
Alternative         9353      	4.1%
Anime               9353      	4.1%
Blues               9353      	4.1%
Children’s Music    9353      	4.1%
Classical           9256      	4.1%
Comedy              9681      	4.2%
Country             8664      	3.8%
Dance               8701      	3.8%
Electronic          9377      	4.1%
Folk                9299      	4.1%
Hip-Hop             9295      	4.1%
Indie               9543      	4.2%
Jazz                9441      	4.1%
Movie               7806      	3.4%
Opera               8280      	3.6%
Pop                 9386      	4.1%
R&B                 8992      	3.9%
Rap                 9232      	4.0%
Reggae              8771      	3.8%
Reggaeton           8927      	3.9%
Rock                9272      	4.1%
Ska                 8874      	3.9%
Soul                9089      	4.0%
Soundtrack          9646      	4.2%
World               9096      	4.0%

Total: 228159

De esta manera, se opta por eliminar tanto los géneros repetidos Anime, Blues y Children's Music, agrupándolos en un solo género Alternative como los géneros con pocas canciones (A Capella)

In [0]:
originDataG = originDataF.copy(deep=True)
deleteGenres = (originDataG.genre == 'Anime') | (originDataG.genre == 'Blues') | \
    (originDataG.genre == "Children’s Music") | (originDataG.genre == 'A Capella')
print('Se eliminaron {0} elementos'.format(deleteGenres.sum()))
originDataG = originDataG[~deleteGenres]

_ = generar_grafico_particion_data(originDataG, 'genre', 'Distribución de los datos por géneros\n\n')
Se eliminaron 28178 elementos
Alternative         9353      	4.7%
Classical           9256      	4.6%
Comedy              9681      	4.8%
Country             8664      	4.3%
Dance               8701      	4.4%
Electronic          9377      	4.7%
Folk                9299      	4.6%
Hip-Hop             9295      	4.6%
Indie               9543      	4.8%
Jazz                9441      	4.7%
Movie               7806      	3.9%
Opera               8280      	4.1%
Pop                 9386      	4.7%
R&B                 8992      	4.5%
Rap                 9232      	4.6%
Reggae              8771      	4.4%
Reggaeton           8927      	4.5%
Rock                9272      	4.6%
Ska                 8874      	4.4%
Soul                9089      	4.5%
Soundtrack          9646      	4.8%
World               9096      	4.5%

Total: 199981

Del gráfico anterior, se observa una distribución uniforme de los géneros (en promedio cada género contiene un 4% de los datos.

Con las canciones filtradas se vuelve a obtener la matriz de correlación sobre los atributos para todas las canciones:

In [0]:
crear_matriz_correlacion(originDataG)
In [0]:
En cuanto a la distribución de los atributos, es la siguiente:
In [0]:
plt.figure(figsize=(10,10), dpi=100)
ax = plt.axes()
originDataG.hist(ax=ax, bins=20, grid=False)
plt.show()
/usr/local/lib/python3.6/dist-packages/IPython/core/interactiveshell.py:2882: UserWarning: To output multiple subplots, the figure containing the passed axes is being cleared
  exec(code_obj, self.user_global_ns, self.user_ns)

Con la exploración de los datros, fue posible obtener conocimiento de cómo están distribuidos los datos y qué tendencia presentan en general y para cada género.

Luego, con el pre-procesamiento de los datos realizado se procedió a realizar experimentos. En particular se realizó clasificación y clustering. Ambos se explican a continuación.

Clasificación

Pre-procesamiento de la data

Una vez procesados los datos se procede a obtener la parte númerica que corresponde a las características técnicas y su clasificación (género/popularidad).

In [0]:
originDataSamplePor = 1.0 # Fracción de los datos que se toman
if originDataSamplePor != 1:
    originDataSample = originDataG.sample(frac=originDataSamplePor, replace=True, random_state=1)
else:
    originDataSample = originDataG
    
# Genero
originDataX_genre = originDataSample.iloc[:, 1:14] # Datos numéricos
originDataY_genre = originDataSample.iloc[:, 0] # Clase (Genre)

# Popularidad
originDataSampleC = originDataSample.copy(deep=True)
del originDataSampleC['genre']

originDataX_popularity = originDataSampleC.iloc[:, 1:14]
originDataY_popularity_data = originDataSampleC.iloc[:, 0] # Popularidad

Tal y como se mencionó antes, se utilizarán clasificadores multi-etiqueta los cuales son:

  • Decision Tree
  • Extra Tree
  • Extra Trees
  • Random Forest
  • KNN

Estos se caracterizan por poder clasificar elementos pueden pertenecer a múltiples clases a la vez. Del hito 2 se observó que KNN a mayor número de vecinos disminuye la precisión, por tanto se deja un total de 3 vecinos.

Adicionalmente, para corrobar que no se produce overfitting se realiza cross-validation. Sin embargo, dado el tamaño de los datos y la capacidad de procesamiento sólo es posible realizarlo sobre el clasificador "Decision Tree".

Clasificador de género

In [0]:
X_train, X_test, y_train, y_test = train_test_split(originDataX_genre, originDataY_genre,
                                                    test_size=0.30, stratify=originDataY_genre)

# https://scikit-learn.org/stable/modules/multiclass.html
c0 = ('Extra Tree Classifier', ExtraTreeClassifier(), True, True)
c1 = ('Decision Tree', DecisionTreeClassifier(), False, True)
c2 = ('Extra Trees Classifier', ExtraTreesClassifier(n_estimators=100), False, True)
c3 = ('Random Forest Classifier', RandomForestClassifier(n_estimators=100), False, True)

# Clasificadores tipo KNN
c4 = ('KNN-3', KNeighborsClassifier(n_neighbors=3), False, True)
c5 = ('KNN-5', KNeighborsClassifier(n_neighbors=5), False, True)
c6 = ('KNN-10', KNeighborsClassifier(n_neighbors=10), False, True)
c7 = ('KNN-25', KNeighborsClassifier(n_neighbors=25), False, True)
c8 = ('KNN-50', KNeighborsClassifier(n_neighbors=50), False, True)
c9 = ('KNN-100', KNeighborsClassifier(n_neighbors=100), False, True)

# Otros, utilizados para realizar gráficos
c10 = ('Decision Tree - Depth3', DecisionTreeClassifier(max_depth=3), False, False)
c11 = ('Extra Trees Classifier - Depth3', ExtraTreeClassifier(max_depth=3), False, False)

# Set de clasificadores y resultados
classifiers = [c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11]
results = {}

# Ejecuta cada uno de los clasificadores
print('Total data entrenamiento: {0}, testing: {1}'.format(len(y_train), len(y_test)))
k = 0
for name, clf, doCrossValidation, printResults in classifiers:
    metrics = run_classifier(clf, originDataX_genre, X_train, X_test,
                             originDataY_genre, y_train, y_test, num_tests=1, cv=5,
                             crossValidation=doCrossValidation)
    if printResults:
        print('----------------')
        if doCrossValidation:
            print('Cross Validation:')
        print('Resultados para clasificador ({0}): {1}'.format(k, name)) 
        print('Tiempo de calculo: {0}s'.format(round(metrics['time'], 2)))
        print('Accuracy promedio:\t', round(metrics['cv-accuracy-mean'], 4))
        print('Precision promedio:\t', round(metrics['cv-precision-mean'], 4))
        print('F1-score promedio:\t', round(metrics['cv-f1-score-mean'], 4))
        print('Recall promedio:\t', round(metrics['cv-recall-mean'], 4))
        print('----------------\n')
    results[name] = metrics
    k += 1
Total data entrenamiento: 139986, testing: 59995
----------------
Cross Validation:
Resultados para clasificador (0): Extra Tree Classifier
Tiempo de calculo: 1.96s
Accuracy promedio:	 0.2379
Precision promedio:	 0.2438
F1-score promedio:	 0.2344
Recall promedio:	 0.2392
----------------

----------------
Resultados para clasificador (1): Decision Tree
Tiempo de calculo: 4.5s
Accuracy promedio:	 0.2899
Precision promedio:	 0.2926
F1-score promedio:	 0.2906
Recall promedio:	 0.2899
----------------

----------------
Resultados para clasificador (2): Extra Trees Classifier
Tiempo de calculo: 42.12s
Accuracy promedio:	 0.378
Precision promedio:	 0.3753
F1-score promedio:	 0.3751
Recall promedio:	 0.378
----------------

----------------
Resultados para clasificador (3): Random Forest Classifier
Tiempo de calculo: 69.25s
Accuracy promedio:	 0.382
Precision promedio:	 0.383
F1-score promedio:	 0.3814
Recall promedio:	 0.382
----------------

----------------
Resultados para clasificador (4): KNN-3
Tiempo de calculo: 41.42s
Accuracy promedio:	 0.32
Precision promedio:	 0.337
F1-score promedio:	 0.3168
Recall promedio:	 0.32
----------------

----------------
Resultados para clasificador (5): KNN-5
Tiempo de calculo: 46.88s
Accuracy promedio:	 0.3461
Precision promedio:	 0.3534
F1-score promedio:	 0.3446
Recall promedio:	 0.3461
----------------

----------------
Resultados para clasificador (6): KNN-10
Tiempo de calculo: 54.25s
Accuracy promedio:	 0.3743
Precision promedio:	 0.3771
F1-score promedio:	 0.3715
Recall promedio:	 0.3743
----------------

----------------
Resultados para clasificador (7): KNN-25
Tiempo de calculo: 68.51s
Accuracy promedio:	 0.3992
Precision promedio:	 0.3987
F1-score promedio:	 0.3947
Recall promedio:	 0.3992
----------------

----------------
Resultados para clasificador (8): KNN-50
Tiempo de calculo: 81.53s
Accuracy promedio:	 0.4073
Precision promedio:	 0.4065
F1-score promedio:	 0.4019
Recall promedio:	 0.4073
----------------

----------------
Resultados para clasificador (9): KNN-100
Tiempo de calculo: 99.92s
Accuracy promedio:	 0.4084
Precision promedio:	 0.406
F1-score promedio:	 0.4015
Recall promedio:	 0.4084
----------------

De los valores anteriores, el clasificador que arroja mejores resultados corresponde a los árboles de decisión. En particular "Random Forest" era quién entregaba los mejores resultados.

Ahora bien, es interesante visualizar el árbol de decisión que se utiliza para realizar la clasificación y así conocer qué atributos son los más importantes al momento de dividir el dataset.

Para esto, se trabaja con un árbol de baja profundidad, dado que sólo que quiere visualizar los primeros atributos.

In [0]:
print(results['Decision Tree'])
{'f1-score': [0.2924024635541532], 'precision': [0.29434478907015593], 'recall': [0.29165763813651135], 'accuracy': [0.29165763813651135], 'y_pred': array(['Dance', 'Alternative', 'Country', ..., 'Ska', 'Opera', 'Reggae'],
      dtype=object), 'y_prob': array([0., 0., 0., ..., 0., 0., 0.]), 'accuracy-mean': 0.29165763813651135, 'f1-score-mean': 0.2924024635541532, 'precision-mean': 0.29434478907015593, 'recall-mean': 0.29165763813651135, 'time': 4.629836082458496, 'cv-precision-mean': 0.29434478907015593, 'cv-recall-mean': 0.29165763813651135, 'cv-f1-score-mean': 0.2924024635541532, 'cv-accuracy-mean': 0.29165763813651135}
In [0]:
# Grafico Decision Tree
name, clf, _, _ = classifiers[10]
print('Arbol de decisión para {0}'.format(name))
dot_data = StringIO()
classes = unique_labels(y_test, results[name]['y_pred'])
export_graphviz(clf, out_file=dot_data, feature_names = originDataX_genre.columns,
                class_names=classes, filled=True, proportion=False)

# Edito el string para eliminar los values
dot = dot_data.getvalue()
todelete = ['value']
lines = dot.split(';')
for i in range(len(lines)):
    j = lines[i] # Almacena la linea
    p_value = j.find('\\nvalue =')
    p_class = j.find('\\nclass =')
    if (p_value == -1 or p_class == -1):
        continue
    j = j[0:p_value] + j[p_class:len(j)]
    lines[i] = j
    
dot = ';'.join(lines)

graph = pydotplus.graph_from_dot_data(dot)  
Image(graph.create_png())
Arbol de decisión para Decision Tree - Depth3
Out[0]:
In [0]:
# Grafico Extra Trees Classifier - Depth3
name, clf, _, _ = classifiers[11]
print('Arbol de decisión para {0}'.format(name))
dot_data = StringIO()
classes = unique_labels(y_test, results[name]['y_pred'])
export_graphviz(clf, out_file=dot_data, feature_names = originDataX_genre.columns,
                class_names=classes, filled=True, proportion=False)

# Edito el string para eliminar los values
dot = dot_data.getvalue()
todelete = ['value']
lines = dot.split(';')
for i in range(len(lines)):
    j = lines[i] # Almacena la linea
    p_value = j.find('\\nvalue =')
    p_class = j.find('\\nclass =')
    if (p_value == -1 or p_class == -1):
        continue
    j = j[0:p_value] + j[p_class:len(j)]
    lines[i] = j
    
dot = ';'.join(lines)

graph = pydotplus.graph_from_dot_data(dot)  
Image(graph.create_png())
Arbol de decisión para Extra Trees Classifier - Depth3
Out[0]:

De los gráficos anteriores, se tiene que los 2 atributos que comienzan la partición del dataset para la clasificación corresponden a popularidad (popularity) y a la cantidad de palabras pronunciadas (speechiness).

Lo anterior puede estar relacionado a la correlación comentada anteriormente sobre la relación que tienen los géneros con la popularidad. En cuanto al speechiness, éste atributo puede ser importante debido a que géneros como Comedy tienen un valor muy alto de palabras dichas a lo largo de la pista, a diferencia de géneros como la música electrónica cuyas canciones poseen pocas palabras.

A modo de evaluar el desempeño se grafica el tiempo y la precisión de cada clasificador:

In [0]:
fig, ax = plt.subplots(figsize=(6,6), dpi=100)

p = [] # Precision
t = [] # Tiempo de cálculo
n = [] # Nombre
for name, _, _, d in classifiers:
    if d:
        n.append(name)
        t.append(results[name]['time'])
        p.append(results[name]['accuracy-mean'])

ax.bar(n, p)
plt.grid()
ax.set(xticklabels=n,
       ylabel='Accuracy',
       xlabel='Algoritmo clasificación')
ax.set_title('Accuracy algoritmos clasificación', fontsize=12)

# Rota el eje
plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
         rotation_mode='anchor')

# Agrega tiempo
axt = ax.twinx()
axt.plot(n, t, 'r.-')
axt.tick_params('y', colors='r')
_ = axt.set_ylabel('Tiempo cálculo (s)', color='r')

Matrices de confusión de los clasificadores

Ya ejecutados los clasificadores antes mencionados y hecho el análsis del tiempo de ejecución y precisión, se procede a determinar las matrices de confusión para observar gráficamente los resultados.

In [0]:
name, clf, _, _ = classifiers[0] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
In [0]:
name, clf, _, _ = classifiers[1] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
In [0]:
name, clf, _, _ = classifiers[2] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
In [0]:
name, clf, _, _ = classifiers[3] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
In [0]:
name, clf, _, _ = classifiers[4] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
In [0]:
name, clf, _, _ = classifiers[8] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)
In [0]:
name, clf, _, _ = classifiers[9] # Indicar que clasificador usar
# _ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión', model_name=name, fz=6, figsize=10)
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)', model_name=name, fz=6, modoPorcentaje=True, normalize=True)

Análisis de resultados de géneros predichos para el mejor clasificador

En base al clasificador con mejor Accuracy (Random Forest Classifier) se muestran los resultados de todos los géneros:

In [0]:
name, clf, _, _ = classifiers[9] # Indicar que clasificador usar
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Alternative', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Classical', model_name=name, cutPor=4)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Comedy', model_name=name, cutPor=2)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Country', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Dance', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Electronic', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Folk', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Hip-Hop', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Indie', model_name=name, cutPor=6)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Jazz', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Movie', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Opera', model_name=name, cutPor=2, showOther=True)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Pop', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'R&B', model_name=name, cutPor=7)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Rap', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Reggae', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Reggaeton', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Rock', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Ska', model_name=name, cutPor=4)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Soul', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'Soundtrack', model_name=name, cutPor=5)
In [0]:
grafico_confusion_genero(y_test, results[name]['y_pred'], 'World', model_name=name, cutPor=5)

Comentarios

En base a la aplicación de los diferentes clasificadores se obtuvo que el tipo árbol Random Forest fue el que alcanzó una mayor precisión. Para los clasificadores KNN la presición aumentó desde 3 a los 100 vecinos, posteriormente no se alcanzó una mejor precisión en los resultados. En cualquier caso el clasificador KNN es el más costoso computacionalmente, tomando un tiempo mucho mayor que el resto de clasifcadores.

Para los algoritmos tipo árbol (Extra Trees y Random Forest), se tiene que ambos pertenecen a lo que se conoce como "grupos de clasificadores" (ensemble classifiers). Estos toman diferentes clasificadores que se entrenan bajo condiciones diferentes y el resultado final es una combinación de todos los resultados.

En el caso de Extra Trees, aplica el clasificador Extra tree en múltiples subconjuntos. A su vez, el clasificador Extra tree se caracteriza por generar la división de los atributos de forma aleatoria. A diferencia de un árbol de decisión que busca escoger los atributos que dividan la data de la mejor forma.

Ahora bien, una vez conocidos los resultados de cada clasificador se analiza las matrices de confusión, en particular, para los clasificadores que presentaron mayor precisión. En una primera instancia se observa que la diagonal (verdaderos positivos) es notoria en cada una de las matrices.

En cuanto a los árboles que se construyen la clasificación se reconocen atributos que logran particionar en mayor forma el dataset, los cuales corresponden a la cantidad de palabras pronunciadas y a la popularidad. Otros atributos que le siguen en la construcción de los árboles se encuentra "instrumentalness" que según la página oficial de Spotify, predice si una pista no contiene voces usando sonidos como "Ooh!" y "Aah!" como instrumentos predictivos. Notar que esto está en relación con el atributo speechiness. De esta forma, se tiene que aparte de la popularidad, el hecho de que la canción contenga palabras o voces es un parámetro inicial para la clasificación.

Por otro, los géneros que presentaron mejor precisión fueron: Comedy, Electronic, Opera, Reggaeton, Ska, Soundtrack y World, mientras que los géneros que obtvieron una menor precisión se encuentran: Rock, Pop, Indie, R&B, Rap y Hip-Hop. Interesante es el caso de estos dos últimos géneros, en donde los falsos negativos son similares entre ambos, mostrando gran similitud (similar a lo que ocurría con los géneros mencionados en el párrafo anterior).

De los gráficos circulares fue posible observar de mejor manera la similitud de los géneros en base a la clasificación multi etiqueta. De estos se obtuvieron resultados evidentes como el de Rap y Hip-Hop mecionado anteriormente y otros más interesantes como Soul que suele clasificarse como Folk y Country.

Clasificador de popularidad

Ahora bien, en base a la relación que hay entre la popularidad actual de una canción y el género al cual pertenece, se realiza clasificación considerando como clase objetivo la popularidad.

Para lo anterior, se escoge todas las canciones y se agrupan en clases discretas de popularidad la cual está en un intervalo de 0 a 100.

In [0]:
# Aquí se convierte la popularidad a un espacio de clases discreto (strings)
v_min = originDataY_popularity_data.min()
v_max = originDataY_popularity_data.max()

# La popularidad se convierte a un espacio de clases discerto
def f(x, num_class=10, class_factor=100, dointeger=True, roundn=2):
    itv = (v_max - v_min) / num_class # Aumenta el intervalo
    j = min(num_class, max(1, int(((x - x%itv)/itv) + int(x%itv > 0)))) # Intervalo ganador
    # Calcula las clases
    c1 = ((j - 1)*itv + v_min)*class_factor
    c2 = (j*itv + v_min)*class_factor
    if dointeger:
        c1 = int(c1)
        c2 = int(c2)
    else:
        c1 = round(c1, roundn)
        c2 = round(c2, roundn)
    return '{0}-{1}'.format(c1, c2)

originDataY_popularity10 = originDataY_popularity_data.map(lambda x: f(x, num_class=10))
_ = generar_grafico_particion_data(originDataY_popularity10, None, 'Distribución de los datos por popularidad (10 clases)\n\n')
0-10                10740     	5.4%
10-20               12810     	6.4%
20-30               25108     	12.6%
30-40               35182     	17.6%
40-50               41259     	20.6%
50-60               43429     	21.7%
60-70               24014     	12.0%
70-80               6452      	3.2%
80-90               880       	0.4%
90-100              107       	0.1%

Total: 199981

Antes de realizar la clasificación se evalúa qué géneros están presentes en cada uno de los tramos de popularidad y de esta manera hacer más evidente la relación género-popularidad.

In [0]:
genera_grafico_genero_popularidad(originDataSample, [[0,10],[10,20],[20,30],[30,40],[40,50],[50,60],[60,70],[70,80],[80,90],[90,100]], porcentaje='popularidad')

De la tabla anterior se observan géneros con alta popularidad (Poy y Hip-Hop) y otros con poca popularidad (Movie y Ópera). Esto se observa mejor en los gráficos circulares que se exponen a continuación:

In [0]:
_ = genera_grafico_genero_tramo_popularidad(originDataSample, 0, 10, plotlim=1)
Alternative         25        	0.2%
Classical           1367      	12.7%
Comedy              368       	3.4%
Country             65        	0.6%
Dance               111       	1.0%
Electronic          55        	0.5%
Folk                9         	0.1%
Indie               2         	0.0%
Jazz                171       	1.6%
Movie               4507      	42.0%
Opera               3739      	34.8%
Pop                 1         	0.0%
R&B                 8         	0.1%
Reggae              93        	0.9%
Reggaeton           9         	0.1%
Rock                10        	0.1%
Ska                 3         	0.0%
Soul                36        	0.3%
Soundtrack          107       	1.0%
World               54        	0.5%

Total: 10740
In [0]:
_ = genera_grafico_genero_tramo_popularidad(originDataSample, 70, 100, plotlim=1)
Alternative         328       	3.7%
Country             143       	1.6%
Dance               982       	11.1%
Electronic          29        	0.3%
Folk                166       	1.9%
Hip-Hop             973       	11.0%
Indie               279       	3.2%
Jazz                13        	0.1%
Movie               5         	0.1%
Pop                 2816      	31.9%
R&B                 453       	5.1%
Rap                 1330      	15.0%
Reggae              23        	0.3%
Reggaeton           234       	2.6%
Rock                915       	10.4%
Ska                 12        	0.1%
Soul                125       	1.4%
Soundtrack          2         	0.0%
World               10        	0.1%

Total: 8838

Notar que hay pocas canciones en tramos de popularidad alta (70-100)% y (0-10)%, el grueso de las canciones está asociada a popularidades menores a 70. Por ello se eliminan aquellas canciones que posean dicha popularidad.

In [0]:
_ = data.hist(column='popularity')
In [0]:
originDataY_popularity_delete = (originDataY_popularity_data >= 0.7) | (originDataY_popularity_data <= 0.1)

originDataX_popularity_del = originDataX_popularity[~originDataY_popularity_delete]
originDataY_popularity_del_data = originDataY_popularity_data[~originDataY_popularity_delete]
originDataSample_del = originDataSample[~originDataY_popularity_delete]

v_min = originDataY_popularity_del_data.min()
v_max = originDataY_popularity_del_data.max()

genera_grafico_genero_popularidad(originDataSample_del, [[10,20],[20,30],[30,40],[40,50],[50,60],[60,70]], porcentaje='popularidad')

Para los datos de popularidad nuevos se realizan distintas particiones en clases discretas de popularidad según tramos.

In [0]:
# Actualiza los máximos
v_min = originDataY_popularity_del_data.min()
v_max = originDataY_popularity_del_data.max()

# Realiza las particiones
originDataY_popularity2 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=2))
originDataY_popularity3 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=3))
originDataY_popularity4 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=4))
originDataY_popularity6 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=6))
originDataY_popularity7 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=7))
originDataY_popularity10 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=10))
originDataY_popularity15 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=15))
originDataY_popularity20 = originDataY_popularity_del_data.map(lambda x: f(x, num_class=20))
In [0]:
_ = generar_grafico_particion_data(originDataY_popularity7, None, 'Distribución de los datos por popularidad (7 clases)\n\n')
19-27               7425      	4.1%
27-35               13433     	7.4%
35-44               27045     	15.0%
44-52               29046     	16.1%
52-60               32581     	18.1%
60-69               70873     	39.3%

Total: 180403

Hecho lo anterior, se realiza clasificar la popularidad de las canciones:

In [0]:
XDataset = originDataX_popularity_del
YDataset = originDataY_popularity7

X_train, X_test, y_train, y_test = train_test_split(XDataset, YDataset,
                                                    test_size=0.30, stratify=YDataset)

# https://scikit-learn.org/stable/modules/multiclass.html
c0 = ('Extra Tree Classifier', ExtraTreeClassifier())
c1 = ('Decision Tree', DecisionTreeClassifier())
c2 = ('Extra Trees Classifier', ExtraTreesClassifier(n_estimators=100))
c3 = ('Random Forest Classifier', RandomForestClassifier(n_estimators=100))
c4 = ('KNN-3', KNeighborsClassifier(n_neighbors=3))

classifiers = [c0, c1, c2, c3, c4]

print('Total data entrenamiento: {0}, testing: {1}'.format(len(y_train), len(y_test)))
results = {}
k = 0
for name, clf in classifiers:
    metrics = run_classifier(clf, XDataset, X_train, X_test,
                             YDataset, y_train, y_test, num_tests=1, crossValidation=False)
    print('----------------')
    print('Resultados para clasificador ({0}): {1}'.format(k, name)) 
    print('Tiempo de calculo: {0}s'.format(round(metrics['time'], 2)))
    print('Precision promedio:\t', round(metrics['precision-mean'], 4))
    print('Recall promedio:\t', round(metrics['recall-mean'], 4))
    print('F1-score promedio:\t', round(metrics['f1-score-mean'], 4))
    print('----------------\n')
    results[name] = metrics
    k += 1
Total data entrenamiento: 126282, testing: 54121
----------------
Resultados para clasificador (0): Extra Tree Classifier
Tiempo de calculo: 2.05s
Precision promedio:	 0.4746
Recall promedio:	 0.4909
F1-score promedio:	 0.4817
----------------

----------------
Resultados para clasificador (1): Decision Tree
Tiempo de calculo: 4.36s
Precision promedio:	 0.4742
Recall promedio:	 0.4911
F1-score promedio:	 0.4815
----------------

----------------
Resultados para clasificador (2): Extra Trees Classifier
Tiempo de calculo: 34.11s
Precision promedio:	 0.5037
Recall promedio:	 0.5343
F1-score promedio:	 0.4982
----------------

----------------
Resultados para clasificador (3): Random Forest Classifier
Tiempo de calculo: 63.73s
Precision promedio:	 0.5077
Recall promedio:	 0.5342
F1-score promedio:	 0.4951
----------------

----------------
Resultados para clasificador (4): KNN-3
Tiempo de calculo: 29.91s
Precision promedio:	 0.414
Recall promedio:	 0.4135
F1-score promedio:	 0.4089
----------------

A modo de evaluar el desempeño se grafica el tiempo y la precisión de cada clasificador:

In [0]:
fig, ax = plt.subplots(figsize=(6,6), dpi=100)

p = [] # Precision
t = [] # Tiempo de cálculo
n = [] # Nombre
for name, _ in classifiers:
    n.append(name)
    t.append(results[name]['time'])
    p.append(results[name]['precision-mean'])

ax.bar(n, p)
plt.grid()
ax.set(xticklabels=n,
       ylabel='Precisión',
       xlabel='Algoritmo clasificación')
ax.set_title('Precisión algoritmos clasificación', fontsize=12)

# Rota el eje
plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
         rotation_mode='anchor')

# Agrega tiempo
axt = ax.twinx()
axt.plot(n, t, 'r.-')
axt.tick_params('y', colors='r')
_ = axt.set_ylabel('Tiempo cálculo (s)', color='r')

Matrices de confusión de los clasificadores

In [0]:
name, clf = classifiers[3] # Indicar que clasificador usar
_ = crear_matriz_confusion(y_test, results[name]['y_pred'], title='Matriz de confusión (%)',
                           model_name=name, fz=6, modoPorcentaje=True, normalize=True, figsize=8)

Reconociendo la útil que puede ser predecir la popularidad que una canción puede tener en Spotify a partir de sus atributos técnicos, se tiene que los valores obtenidos en cuanto a precisisón son bajos.

En efecto, estos fueron menores al 50%, lo cual no permite predecir de forma confiable qué tan popular será una canción con una combinación determinada de características técnicas.

Clustering

Una vez realizado el primer experimento que consistía a clasificación, se procede a la aplicación de algoritmos de clustering sobre los atributos técnicos.

Luego, se cruzan los resultados obtenidos del clustering con los géneros asociados a cada canción para ver si hay géneros que se agrupan dentro de un mismo cluster, o si hay clusters en que hay presencia dominante de un único género musical.

Operación sobre la data

In [0]:
originDataSamplePor = 1.0 # Fracción de los datos que se toman
if originDataSamplePor != 1:
    originDataSample = originDataG.sample(frac=originDataSamplePor, replace=True, random_state=1)
else:
    originDataSample = originDataG
originDataX = originDataSample.iloc[:, 1:14] # Datos numéricos
originDataY = originDataSample.iloc[:, 0] # Clase (Genre)

genres = originDataY.unique()
genres.sort()
genreStringLen = 0
for i in range(len(genres)): # Calcula el largo del string del genero
    genreStringLen = max(genreStringLen, len(genres[i]))
    
originDataX.head()
Out[0]:
popularity acousticness danceability energy instrumentalness liveness loudness speechiness tempo valence time_signatureM
0 0.21 0.989959 0.259473 0.231211 0.000431 0.08673 0.727644 0.033609 0.231905 0.0886 0.75
1 0.18 0.975903 0.307092 0.201181 0.028028 0.12333 0.622662 0.037125 0.422847 0.3690 0.75
2 0.10 0.938754 0.112563 0.470450 0.020420 0.35333 0.839583 0.016649 0.186564 0.0696 0.50
3 0.17 0.964858 0.195643 0.006036 0.000000 0.11033 0.362525 0.026680 0.192263 0.0380 0.75
4 0.19 0.988955 0.086221 0.058038 0.146146 0.08723 0.549631 0.028025 0.594360 0.0382 0.75

Clustering, creación de los kmeans

In [0]:
# Crea clustering a distintos clusters
clusters = {}
ncluster = [2, 3, 5, 10, 13, 26] # Qué clusters se analizarán
distorsiones = []

for n_clusters in ncluster:
    t0 = time.time()
    k_means = KMeans(n_clusters=n_clusters, random_state=n_clusters) # Crea el Kmeans
    k_means.fit(originDataX)
    t_batch = time.time() - t0
    print('Tiempo de generación {1} clusters:\t{0}s'.format(round(t_batch, 2), n_clusters))
    
    # Agrega la distorsión
    dst = sum(np.min(cdist(originDataX, k_means.cluster_centers_, 'euclidean'),
                     axis=1)) / originDataX.shape[0]
    distorsiones.append(dst)
    
    # Realiza análisis por cada género
    genreKmean = []
    k_means_cluster_centers = np.sort(k_means.cluster_centers_, axis=0)
    k_means_labels = pairwise_distances_argmin(originDataX, k_means_cluster_centers)

    # Porcentajes por cada cluster
    genrePor = {}
    genreMat = np.zeros((len(genres), n_clusters)) # Porcentaje por cada genero
    genreClusterMat =  np.zeros((len(genres), n_clusters)) # Porcentaje por cada cluster

    # Obtiene los generos de cada cluster
    for i in range(len(genres)):
        
        # Vector con porcentajes
        gp = [0]*(n_clusters+1) # El último tiene el total
        total = 0

        for k in range(n_clusters):
            my_members = k_means_labels == k
            clusterData = originDataSample.iloc[my_members, 0]
            gp[k] = (clusterData == genres[i]).sum()
            total += gp[k]
        gp[len(gp) - 1] = total

        # Calcula los porcentajes
        for k in range(n_clusters):
            genreMat[i][k] = gp[k] / total * 100
            genreClusterMat[i][k] = gp[k]

        genrePor[genres[i]] = gp
        for k in range(n_clusters):
            genrePor[genres[i]][k] /= total


    # Divide por el total para el porcentaje por cluster
    for i in range(n_clusters):
        s = int(genreClusterMat[:, i].sum()) # Suma datos del cluster
        # print('Cluster {0}, N° datos: {1}'.format(i+1, s))
        if s != 0:
            genreClusterMat[:, i] = genreClusterMat[:, i] / s * 100
    
    # Guarda los resultados
    clusters[n_clusters] = {'kmeans': k_means,
                            'time': t_batch,
                            'nclusters': n_clusters,
                            'distortion': dst,
                            'genrePor': genrePor,
                            'genreMat': genreMat,
                            'genreClusterMat': genreClusterMat,
                            'clusterCenter':k_means_cluster_centers,
                            'labels': k_means_labels
                           }
Tiempo de generación 2 clusters:	2.09s
Tiempo de generación 3 clusters:	2.96s
Tiempo de generación 5 clusters:	3.86s
Tiempo de generación 10 clusters:	14.86s
Tiempo de generación 13 clusters:	29.07s
Tiempo de generación 26 clusters:	67.32s

El siguiente gráfico ilustra el número de clusters realizado y el tiempo de cálculo:

In [0]:
fig, ax = plt.subplots(figsize=(6,6), dpi=100)

t = [] # Tiempo de cálculo
n = [] # Nombre
for i in ncluster:
    t.append(clusters[i]['time'])

ax.plot(ncluster, t, '.-')
plt.grid()
ax.set(ylabel='Tiempo de cálculo (s)',
       xlabel='Número de clusters')
_ = ax.set_title('Tiempo de cálculo', fontsize=12)

A continuación se grafica la distorsión en función del número de clusters y aplicando el método de Elbow (o del codo) se escoge el número óptimo de clusters:

In [0]:
fig, ax = plt.subplots(figsize=(6,6), dpi=100)
ax.plot(ncluster, distorsiones, 'bx-')
ax.xlabel('k')
ax.ylabel('Distorsión')
ax.title('Método de Elbow para encontrar el K óptimo')
plt.grid()
plt.show()

Del gráfico anterior se observa que con 10 clusters es posible obtener una buena aproximación al número de clusters, a partir del cual el error no baja de forma significativa.

Gráfico de clusters

A continuación se muestra un gráfico para un cluster y el género:

In [0]:
generar_grafico_kmeans(originDataX, originDataSample, clusters[10]['kmeans'], 0, 1, cluster=[2,1])
Graficando clusters KMEANS, columnas 0x1 (acousticness x danceability)
Posicion centro cluster 1: (0.10870966952716188,0.2713778942039213)
Posicion centro cluster 2: (0.1756605351624575,0.4248577641830906)
In [0]:
generar_grafico_kmeans(originDataX, originDataSample, clusters[10]['kmeans'], 0, 7, cluster=[1,2], genre=['Comedy'], gSize=3)
Graficando clusters KMEANS, columnas 0x7 (popularity x speechiness)
Posicion centro cluster 1: (0.18836113981987637,0.0233150091627721)
Posicion centro cluster 2: (0.20711273486430085,0.039003634022857475)
In [0]:
generar_grafico_kmeans_pairs(originDataX, originDataSample, clusters[10]['kmeans'], cluster=[1,2], genre=['Comedy'])

Análisis de resultados

A continuación se busca calcular el porcentaje de repartición de cada género en los clusters calculados, ello es, iterar por género y ver el % de aparición en cada cluster generado.

In [0]:
generar_tabla_generos(clusters[10])
Genero\Cluster	1	2	3	4	5	6	7	8	9	10	

Alternative	0.0	0.6	5.2	18.3	58.6	9.2	6.0	1.4	0.6	0.0	
Classical  	0.1	24.4	0.4	2.7	4.3	0.9	34.0	1.0	32.2	0.0	
Comedy     	0.0	0.0	0.3	0.1	1.6	3.3	18.6	12.1	0.1	63.9	
Country    	0.0	0.1	4.8	9.7	55.2	17.2	9.4	3.4	0.1	0.0	
Dance      	0.0	0.0	3.1	15.1	67.4	10.4	2.3	1.0	0.7	0.0	
Electronic 	0.0	0.6	3.6	26.4	55.7	4.5	2.0	0.4	6.9	0.0	
Folk       	0.0	1.3	7.3	8.4	29.3	17.3	29.2	4.7	2.6	0.0	
Hip-Hop    	0.0	0.0	2.9	17.4	62.7	12.0	2.5	2.2	0.1	0.0	
Indie      	0.0	0.5	5.7	13.5	42.6	13.9	19.0	2.7	2.1	0.0	
Jazz       	0.0	1.4	4.2	6.3	26.4	13.7	21.1	8.0	18.7	0.1	
Movie      	0.0	5.6	2.5	1.9	10.9	14.9	42.5	14.6	6.5	0.5	
Opera      	0.0	16.4	0.0	0.0	0.0	0.4	71.3	1.4	10.5	0.0	
Pop        	0.0	0.2	3.4	15.2	57.0	13.9	7.5	2.4	0.3	0.0	
R&B        	0.0	0.3	7.0	15.1	45.5	18.3	10.7	2.5	0.7	0.0	
Rap        	0.0	0.0	3.0	18.7	62.5	11.2	2.6	1.8	0.1	0.0	
Reggae     	0.0	0.1	1.4	4.7	74.7	11.5	2.3	4.4	0.9	0.0	
Reggaeton  	0.0	0.0	0.2	3.4	71.5	17.4	1.3	6.1	0.0	0.0	
Rock       	0.0	0.7	5.0	13.5	60.0	11.0	6.6	2.3	0.9	0.0	
Ska        	0.0	0.0	0.7	5.2	83.0	5.7	0.8	2.8	1.7	0.0	
Soul       	0.0	0.5	7.0	12.5	38.2	20.1	15.1	4.9	1.9	0.0	
Soundtrack 	0.9	42.2	5.3	3.8	1.1	3.2	20.7	0.2	22.7	0.0	
World      	0.1	8.1	12.0	23.0	19.3	8.9	15.9	3.0	9.7	0.0	

Luego se visualizan los resultados tanto por género como por cluster. Es decir, se analiza la distribución de las canciones en cada uno de los clúster y la distribución de las canciones de cada cluster y su distribución en cada uno de los géneros.

La finalidad de esto es ver si hay clusters puros (con un género) o que contienen géneros con características similares.

In [0]:
generar_particion_genero_cluster(clusters[10]['genreMat'], 'Partición género por cada cluster')
In [0]:
generar_particion_genero_cluster(clusters[10]['genreClusterMat'], 'Partición cluster por cada género')

Comentarios

En primera instancia se observa que la mayoría de los géneros se concentran en un sólo cluster (cluster 5). Sin embargo, hay géneros como Comedy cuyas canciones se agrupan en un sólo cluster (10) y a su vez dicho cluster contiene sólo canciones de dicho género. Por lo tanto se infiere que este género en particular tiene una combinación de atributos técnicos particulares que lo diferencian del resto de géneros.

Además se tienen cluster que logran agrupar géneros con atributos similares como el clsuter 2 que agrupa principalmente canciones asociadas a Ópera, Soundtrack y Classical.

Dado que casi todos los géneros están el cluster 5, se analiza éste aplicando clustering sobre el subconjunto de canciones:

In [0]:
k_means_labels = clusters[10]['labels']
my_members = k_means_labels == 4 # Cluster N°5
cluster5data = originDataSample[my_members]

originDataX = cluster5data.iloc[:, 1:14] # Datos numéricos
originDataY = cluster5data.iloc[:, 0] # Clase (Genre)
In [0]:
# Crea clustering a distintos clusters
clusters5data = {}
ncluster = [2, 3, 5, 10, 13, 26] # Qué clusters se analizarán
distorsiones = []

for n_clusters in ncluster:
    t0 = time.time()
    k_means = KMeans(n_clusters=n_clusters, random_state=n_clusters) # Crea el Kmeans
    k_means.fit(originDataX)
    t_batch = time.time() - t0
    print('Tiempo de generación {1} clusters:\t{0}s'.format(round(t_batch, 2), n_clusters))
    
    # Agrega la distorsión
    dst = sum(np.min(cdist(originDataX, k_means.cluster_centers_, 'euclidean'),
                     axis=1)) / originDataX.shape[0]
    distorsiones.append(dst)
    
    # Realiza análisis por cada género
    genreKmean = []
    k_means_cluster_centers = np.sort(k_means.cluster_centers_, axis=0)
    k_means_labels = pairwise_distances_argmin(originDataX, k_means_cluster_centers)

    # Porcentajes por cada cluster
    genrePor = {}
    genreMat = np.zeros((len(genres), n_clusters)) # Porcentaje por cada genero
    genreClusterMat =  np.zeros((len(genres), n_clusters)) # Porcentaje por cada cluster

    # Obtiene los generos de cada cluster
    for i in range(len(genres)):
        
        # Vector con porcentajes
        gp = [0]*(n_clusters+1) # El último tiene el total
        total = 0

        for k in range(n_clusters):
            my_members = k_means_labels == k
            clusterData = cluster5data.iloc[my_members, 0]
            gp[k] = (clusterData == genres[i]).sum()
            total += gp[k]
        gp[len(gp) - 1] = total
        genrePor[genres[i]] = gp
        
        if total != 0:
            
            # Calcula los porcentajes
            for k in range(n_clusters):
                genreMat[i][k] = gp[k] / total * 100
                genreClusterMat[i][k] = gp[k]

            for k in range(n_clusters):
                genrePor[genres[i]][k] /= total


    # Divide por el total para el porcentaje por cluster
    for i in range(n_clusters):
        s = int(genreClusterMat[:, i].sum()) # Suma datos del cluster
        # print('Cluster {0}, N° datos: {1}'.format(i+1, s))
        if s != 0:
            genreClusterMat[:, i] = genreClusterMat[:, i] / s * 100
    
    # Guarda los resultados
    clusters5data[n_clusters] = {'kmeans': k_means,
                            'time': t_batch,
                            'nclusters': n_clusters,
                            'distortion': dst,
                            'genrePor': genrePor,
                            'genreMat': genreMat,
                            'genreClusterMat': genreClusterMat,
                            'clusterCenter':k_means_cluster_centers,
                            'labels': k_means_labels
                           }
Tiempo de generación 2 clusters:	1.87s
Tiempo de generación 3 clusters:	1.74s
Tiempo de generación 5 clusters:	3.14s
Tiempo de generación 10 clusters:	10.99s
Tiempo de generación 13 clusters:	14.38s
Tiempo de generación 26 clusters:	39.37s
In [0]:
generar_particion_genero_cluster(clusters5data[10]['genreMat'], 'Partición género por cada cluster')

Pese a que se tiene una mejor distribución de los géneros en los diferentes clusters, no es posible afirmar que un cluster esté asociado a uno o más clusters de forma significativa.

Lo anterior tiene relación con las múltiples variaciones presentes en cada género (sub-géneros). Esto genera que una canción pueda estar asociada a más de un género o simplemente, que una canción no pueda encasillarse en los géneros que componen el dataset.

Conclusiones

De los experimentos realizados tanto en clasificación como en clustering, se obtiene en una primera instancia que los árboles de decisión fueron los clasificadores que presentaron una mayor precisión al estimar géneros multi-etiqueta. Tanto Random Forest como Extra Trees dieron los mejores resultados. Lo anterior valida en parte la hipótesis inicial, la cual establece que los géneros pueden quedar definidos en base a sólo sus características técnicas.

Ahora bien, se reconoce el atributo de popularidad pese a no ser una métrica técnica u objetiva, tiene una relación importante con el género al cual pertenece una canción. Cabe destacar que éste atributo es un parámetro que indirectamente indica los gustos musicales de la sociedad contemporánea. En efecto, se observa que géneros como Rap y Reggaeton son más populares que Opera o la música Clásica. Lo anterior tiene componentes culturales e históricas que escapan al alcance de éste trabajo. Sin embargo que abre una posible línea de investigación que busque analizar cómo la música ha evolucionado en paralelo con la cultura dominante (o de masas) a lo largo de la tiempo. Se debe mencionar que este atributo contribuye demasiado a una mejora de los resultados de los algoritmos de clasificación, debido a que como se mencionó en el Hito 1, este no correlaciona con casi ningún atributo técnico y tiene mucha incidencia en el género de la canción.

En cuanto a Clustering, los resultados permitieron observar que hay géneros muy marcados como Comedy o Soundtrack que quedaban concentrados en un único cluster. Sin embargo, se tiene un cluster que concentra canciones de muchos géneros musicales. Esto deja entrever la similitud que poseen ciertos géneros en base a sus características, como es el caso del Rock que como género musical tiene muchas variaciones (subgéneros). Además, dada esta agrupación entre los géneros se reconoce que una canción puede asociarse a más de un género musical.

Para la clasificación, fue posible reconocer que los atributos que más peso tienen para la partición de los datos (en los árboles de decisión) son aquellos asociados a la presencia y cantidad de voces o palabras que contiene una pista. También la popularidad es determinante dentro del proceso de clasificación. Luego, si se analiza esto con lo obtenido en el proceso de clustering se tiene que géneros como Comedy y Opera son géneros con alto contenido de voces y bajo nivel de popularidad. Es por ello que lograron diferenciarse de forma notoria del resto de los géneros al visualizar la asignación de los canciones a cada cluster.

Por último, de los gráficos circulares se analizan las similitudes que tienen los géneros luego de haber aplicado el proceso de clasificación. Con esto, se tiene en una primera instancia que los géneros sí poseen similitudes, las cuales son a nivel de atributos técnicos. Además al utilizar clasificadores multi etiqueta se evidencia que los géneros son clasificables en base a estos atributos. Lo anterior es una conclusión importante si se considera que para clasificar no se toma en cuenta la letra de la canción o el artista.

Unas posibles mejoras al trabajo ya realizado, seria considerar estudiar las métricas de evaluación que son especialmente diseñadas para clasificadores multi-etiquetas, para poder evaluar mejor el comportamiento de los distintos algoritmos que se aplicaron. Por otro lado, se podría estudiar más detenidamente una reducción de dimensionalidad tanto para clasificar como para realizar clastering, todo esto en busca de mejorar los resultados previamente obtenidos ya sea eliminando ruido de los datos o información redundante.